iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Modern Web

Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站系列 第 26

Day 26 Vue 狀態管理 – 用 Pinia 建立集中式 Store(含持久化)

  • 分享至 

  • xImage
  •  

今日目標

  • 安裝與啟用 Pinia
  • 建立兩個 store:useUiStore(UI 狀態)與 useProjectsStore(資料/快取)
  • 替換原本的組合式函式/元件內部狀態,統一走 store
  • 加上 持久化(主題、篩選、關鍵字)
  • 留好後續接 API 的擴充點(錯誤處理、loading、重試)

1) 安裝與註冊 Pinia

npm i pinia

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import './styles/base.css'

createApp(App)
  .use(router)
  .use(createPinia())
  .mount('#app')


2) 建立 UI Store(主題 / 技能分類 / 關鍵字)

// src/stores/ui.ts
import { defineStore } from 'pinia'
type Theme = 'light' | 'dark'
type Cat = 'all' | 'frontend' | 'backend' | 'tools'

export const useUiStore = defineStore('ui', {
  state: () => ({
    theme: 'light' as Theme,
    skillCategory: 'all' as Cat,
    keyword: '' as string,
  }),
  getters: {
    isDark: (s) => s.theme === 'dark',
  },
  actions: {
    setTheme(theme: Theme) {
      this.theme = theme
      // 立即反映到 <html data-theme=...>
      document.documentElement.setAttribute('data-theme', theme)
      // 持久化(簡易版)
      localStorage.setItem('ui.theme', theme)
    },
    toggleTheme() {
      this.setTheme(this.isDark ? 'light' : 'dark')
    },
    setSkillCategory(cat: Cat) { this.skillCategory = cat; localStorage.setItem('ui.cat', cat) },
    setKeyword(kw: string) { this.keyword = kw; localStorage.setItem('ui.kw', kw) },
    // 初始化:將 localStorage 的值載回
    initFromStorage() {
      const t = localStorage.getItem('ui.theme') as Theme | null
      const c = localStorage.getItem('ui.cat') as Cat | null
      const k = localStorage.getItem('ui.kw')
      if (t) this.setTheme(t)
      if (c) this.skillCategory = c
      if (k !== null) this.keyword = k
    }
  }
})

小提醒:我們直接在 action 裡同步更新 document.documentElement,確保切換主題立即生效。


3) 建立 Projects Store(列表 / 快取 / 載入狀態)

// src/stores/projects.ts
import { defineStore } from 'pinia'

export type Project = {
  id: number
  slug: string
  title: string
  tech: string
  desc: string
  tags: string[]
  images: string[]
  demo: string
  repo: string
  featured: boolean
}

export const useProjectsStore = defineStore('projects', {
  state: () => ({
    items: [] as Project[],
    loaded: false,
    loading: false,
    error: null as Error | null,
    lastFetchedAt: null as number | null,
  }),
  getters: {
    count: (s) => s.items.length,
    bySlug: (s) => (slug: string) => s.items.find(p => p.slug === slug),
  },
  actions: {
    async fetchAll(force = false) {
      if (this.loaded && !force) return
      this.loading = true
      this.error = null
      try {
        // 你可以改成真的 API,例如:/api/projects
        const res = await fetch('/projects.json', { cache: 'no-cache' })
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        const data = await res.json() as Project[]
        this.items = data
        this.loaded = true
        this.lastFetchedAt = Date.now()
      } catch (err: any) {
        this.error = err
        this.items = []
        this.loaded = false
      } finally {
        this.loading = false
      }
    }
  }
})


4) 在 App 啟動時初始化(主題等狀態)

// src/App.vue
<script setup lang="ts">
import { RouterView } from 'vue-router'
import SiteHeader from '@/components/SiteHeader.vue'
import SiteFooter from '@/components/SiteFooter.vue'
import { onMounted } from 'vue'
import { useUiStore } from '@/stores/ui'

const ui = useUiStore()
onMounted(() => {
  ui.initFromStorage()            // 從 localStorage 讀回
  // 初次掛載依 Theme 設置 data-theme(init 已做,不過再保險一次)
  document.documentElement.setAttribute('data-theme', ui.theme)
})
</script>

<template>
  <SiteHeader />
  <RouterView />
  <SiteFooter />
</template>


5) 改寫 Header:用 store 切換主題

<!-- src/components/SiteHeader.vue -->
<template>
  <header class="site-header">
    <div class="container">
      <a class="brand" href="#home" aria-label="回到頁面頂部">Chiayu</a>
      <nav class="site-nav" aria-label="主選單">
        <ul>
          <li><a href="#about">關於我</a></li>
          <li><a href="#skills">技能</a></li>
          <li><a href="#projects">作品</a></li>
          <li><a href="#contact">聯絡</a></li>
        </ul>
      </nav>
      <button class="btn btn-outline small" type="button" @click="ui.toggleTheme()">
        {{ ui.isDark ? '切換為亮色' : '切換為暗色' }}
      </button>
    </div>
  </header>
</template>

<script setup lang="ts">
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
</script>


6) 改寫 Skills:分類與關鍵字走 store

<!-- src/components/Skills.vue -->
<template>
  <section id="skills" class="container section" aria-labelledby="skills-title">
    <div class="section-header">
      <h2 id="skills-title">技能 Skillset</h2>
      <div role="tablist" aria-label="技能分類" class="filters">
        <button class="chip" role="tab" :aria-selected="ui.skillCategory==='all'"      @click="ui.setSkillCategory('all')">全部</button>
        <button class="chip" role="tab" :aria-selected="ui.skillCategory==='frontend'" @click="ui.setSkillCategory('frontend')">前端</button>
        <button class="chip" role="tab" :aria-selected="ui.skillCategory==='backend'"  @click="ui.setSkillCategory('backend')">後端</button>
        <button class="chip" role="tab" :aria-selected="ui.skillCategory==='tools'"    @click="ui.setSkillCategory('tools')">工具</button>
      </div>
    </div>

    <div class="field" style="margin:12px 0;">
      <label for="skill-search">關鍵字搜尋</label>
      <input id="skill-search" type="text" :value="ui.keyword" @input="onInput" placeholder="例如:Vue、Docker…" />
    </div>

    <ul class="skill-grid">
      <li v-for="s in filtered" :key="s.name">{{ s.name }}</li>
    </ul>
  </section>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useUiStore } from '@/stores/ui'
import { skills } from '@/data/skills'
const ui = useUiStore()

// 簡易 debounce
let tid: number | undefined
function onInput(e: Event) {
  const v = (e.target as HTMLInputElement).value
  window.clearTimeout(tid)
  tid = window.setTimeout(() => ui.setKeyword(v.trim()), 300)
}

const filtered = computed(() => {
  const kw = ui.keyword.toLowerCase()
  return skills.filter(s => {
    const byCat = ui.skillCategory === 'all' || s.category === ui.skillCategory
    const byKw  = !kw || s.name.toLowerCase().includes(kw)
    return byCat && byKw
  })
})
</script>


7) 改寫 Projects(列表)使用 Projects Store

<!-- src/components/Projects.vue -->
<template>
  <section id="projects" class="container section" aria-labelledby="projects-title">
    <h2 id="projects-title">作品集 Projects</h2>

    <div class="field" style="margin:12px 0;">
      <label><input type="checkbox" v-model="onlyFeatured" /> 只看精選</label>
    </div>

    <p v-if="ps.loading">載入中...</p>
    <p v-else-if="ps.error" class="error">載入失敗,請稍後再試。</p>

    <div v-else class="project-grid">
      <article class="card" v-for="p in view" :key="p.id">
        <h3>{{ p.title }}</h3>
        <p class="muted">{{ p.tech }}</p>
        <p>{{ p.desc }}</p>
        <div style="display:flex; gap:8px; margin-top:8px;">
          <RouterLink class="btn small" :to="{ name:'project-detail', params:{ slug: p.slug } }">查看詳情</RouterLink>
          <a class="btn small btn-outline" :href="p.repo" target="_blank" rel="noopener">GitHub</a>
        </div>
      </article>
    </div>
  </section>
</template>

<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useProjectsStore } from '@/stores/projects'
const ps = useProjectsStore()

const onlyFeatured = ref(false)
const view = computed(() => {
  const list = ps.items
  return onlyFeatured.value ? list.filter(p => p.featured) : list
})

onMounted(() => { ps.fetchAll().catch(() => {}) })
</script>


8) 詳情頁使用 store(若未載入先抓一次)

<!-- src/views/ProjectDetail.vue -->
<template>
  <section class="container section">
    <nav style="margin-bottom:12px;">
      <RouterLink to="/" class="btn btn-outline">← 返回列表</RouterLink>
    </nav>

    <p v-if="ps.loading">載入中...</p>
    <p v-else-if="ps.error" class="error">讀取失敗,請返回列表。</p>

    <template v-else>
      <section v-if="project">
        <h2>{{ project.title }}</h2>
        <p class="muted">{{ project.tech }}</p>
        <div class="gallery" v-if="project.images?.length" style="display:flex; gap:12px; flex-wrap:wrap; margin:12px 0;">
          <img v-for="src in project.images" :key="src" :src="src" alt="專案截圖" width="360" />
        </div>
        <p>{{ project.desc }}</p>
        <div style="display:flex; gap:8px; margin-top:8px;">
          <a class="btn" :href="project.demo" target="_blank" rel="noopener">Live Demo</a>
          <a class="btn btn-outline" :href="project.repo" target="_blank" rel="noopener">GitHub</a>
        </div>
      </section>

      <section v-else>
        <h2>找不到這個專案</h2>
        <p class="muted">請回到列表,或確認網址是否正確。</p>
        <RouterLink to="/" class="btn">返回列表</RouterLink>
      </section>
    </template>
  </section>
</template>

<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useProjectsStore } from '@/stores/projects'
const route = useRoute()
const ps = useProjectsStore()

const slug = computed(() => String(route.params.slug || ''))
const project = computed(() => ps.bySlug(slug.value))

onMounted(async () => {
  if (!project.value && !ps.loading) {
    try { await ps.fetchAll() } catch {}
  }
})
</script>


9) (可選)更完整的持久化方式

如果想讓 Pinia 自動持久化,安裝插件 pinia-plugin-persistedstate

npm i pinia-plugin-persistedstate

// main.ts
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPersist)
createApp(App).use(router).use(pinia).mount('#app')

// ui.ts(加入 persist)
export const useUiStore = defineStore('ui', {
  /* ...state/getters/actions 同上... */
  persist: {
    key: 'ui',
    paths: ['theme', 'skillCategory', 'keyword'] // 只存這些欄位
  }
})


成果

  • 狀態集中:主題、技能篩選、關鍵字、專案列表與載入狀態,全都在 Pinia。
  • 持久化:重整後仍保留主題與篩選設定。
  • 可維護:列表與詳情共用同一份快取;之後要換成真 API/加攔截器也只動 store。

小心踩雷(常見誤用 → 正確)

  1. 在 getter 內做副作用
    • getter 裡改 DOM 或寫 localStorage
    • ✅ 副作用放在 action
  2. 過度拆 store
    • 小專案兩三個 store 足夠;太多反而難維護
  3. store 與元件重複管理同一份 state
    • 譬如元件裡還用 ref 存 keyword,同時 store 也存 → 產生不同步
    • ✅ 只存一份在 store;元件讀取並發 dispatch(呼叫 actions)
  4. 未處理錯誤/載入狀態
    • ✅ store 內維護 loading/error,UI 顯示三態(loading / error / data)

下一步(Day 27 預告)

Vue 版收尾與最佳化 & 部署

  • views / components / stores / router / styles 的專案結構
  • 路由 lazy-loading、SEO meta、圖片優化(<link rel="preload"> / srcset
  • Build 與部署(Vercel / GitHub Pages)
  • 宣告 Vue 章節完成,下一階段進入 React

上一篇
Day 25 Vue 非同步資料 – fetch/axios 串接、Loading、Error、快取
下一篇
Day 27 Vue 版履歷網站收尾與部署
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言